22장. 고루틴과 채널 기초
드디어 Go 의 간판 기능에 도착했다. 바로 동시성(concurrency)이다.
Go 의 동시성은 두 개의 도구를 중심으로 돈다.
- 고루틴 — 가벼운 실행 단위
- 채널 — 고루틴 사이의 통로
이 장의 목표는 다음과 같다.
- 고루틴이 무엇이며 왜 가벼운지 이해하기
go키워드로 동시 작업을 시작하기- 채널로 고루틴 사이에 값을 주고받기
select,WaitGroup같은 기본 도구 익히기
문법은 단순하지만 사고방식은 처음에 낯설다. 짧은 예제 여러 개로 천천히 익혀 보자.
22.1 고루틴이란
고루틴(goroutine) 은 Go 런타임이 관리하는 가벼운 실행 단위다.
다른 언어의 스레드와 비슷한 자리에 있지만, 무게가 한참 다르다.
OS 스레드와의 차이
OS 가 직접 다루는 스레드는 꽤 무거운 자원이다.
| 항목 | OS 스레드 | 고루틴 |
|---|---|---|
| 시작 비용 | 무겁다 (수십 µs) | 가볍다 (수 µs 이하) |
| 스택 크기 | 보통 1~8 MB 고정 | 시작 2~8 KB, 필요시 증가 |
| 컨텍스트 스위치 | 커널 호출 | Go 런타임이 직접 |
| 동시에 띄울 수 있는 수 | 수천 개 정도 | 수십만 개도 가능 |
수치는 환경마다 다르지만 차수 자체가 다르다는 점이 중요하다.
고루틴 1만 개를 띄워도 시스템이 멈추지 않는다. OS 스레드를 1만 개 띄우려 했다면 보통은 그 전에 시스템이 비명을 지른다.
Go 런타임 스케줄러
고루틴이 가벼울 수 있는 이유는 Go 런타임 스케줄러가 따로 있기 때문이다.
대강의 그림은 이렇다.
- OS 스레드 몇 개가 워커 풀처럼 떠 있다
- 우리가 만든 수많은 고루틴이 그 워커 위에서 돌아간다
- 어느 고루틴을 어느 스레드에 올릴지는 Go 런타임이 알아서 결정한다
즉, 고루틴은 OS 스레드 위에 한 겹 더 얹은 사용자 영역의 가벼운 스레드라고 이해하면 된다.
핵심 한 줄
고루틴은 OS 스레드보다 훨씬 가벼운 Go 런타임의 실행 단위다. 그래서 수십만 개도 무리 없이 띄울 수 있다.
22.2 고루틴 시작하기
문법은 놀라울 만큼 단순하다.
함수 호출 앞에 go 키워드만 붙이면 된다.
go f()
이 한 줄의 의미는,
f()를 새로운 고루틴에서 실행한다- 호출한 쪽은 기다리지 않고 즉시 다음 줄로 진행한다
첫 예제
package main
import (
"fmt"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("hello")
say("world")
}
say("hello") 는 고루틴에서,
say("world") 는 main 에서 동시에 돈다.
출력은 환경에 따라 섞여 나온다.
world 0
hello 0
hello 1
world 1
world 2
hello 2
순서가 매번 같지 않다는 점이 중요하다. 고루틴의 실행 순서는 보장되지 않는다.
익명 함수도 OK
별도로 함수를 정의하지 않고 익명 함수를 그 자리에서 실행할 수도 있다.
go func() {
fmt.Println("from goroutine")
}()
마지막 () 가 익명 함수를 즉시 호출하는 부분이다.
이 호출에 go 가 붙어 고루틴이 된다.
매개변수도 전달할 수 있다.
name := "Alice"
go func(n string) {
fmt.Println("hi", n)
}(name)
클로저로 바깥 변수를 그냥 캡처하는 것도 가능하지만,
for루프 안에서 잡을 때 흔한 함정이 있다. 그 함정은 25장에서 다시 다룬다.
22.3 main 종료와 고루틴
여기서 초보가 거의 100% 만나는 함정이 있다.
“왜 출력이 안 나오죠?”
다음 코드를 실행해 보자.
package main
import "fmt"
func main() {
go fmt.Println("hello from goroutine")
}
결과는,
(아무것도 안 나옴)
원인은 한 줄로 요약된다.
main 함수가 끝나면 모든 고루틴은 강제로 종료된다.
go fmt.Println(...) 를 호출하자마자
main 은 다음 줄로 넘어가고,
그 다음 줄이 없으니 그대로 끝난다.
고루틴이 실제로 실행될 틈이 없다.
임시방편: time.Sleep
가장 단순한 회피책은 main 을 잠깐 재우는 것이다.
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("hello from goroutine")
time.Sleep(100 * time.Millisecond)
}
이번엔 출력이 나온다.
hello from goroutine
하지만 이 방식은 좋은 해법이 아니다.
- 얼마를 자야 하는지 정확히 알 수 없다
- 너무 짧게 자면 여전히 못 끝낸다
- 너무 길게 자면 프로그램이 느려진다
time.Sleep 은 예제용 임시방편일 뿐이다.
실전에선 다음 두 가지 도구를 쓴다.
- 채널로 끝났다는 신호를 받기
sync.WaitGroup으로 일제히 기다리기
둘 다 이번 장에서 배운다.
22.4 채널이란
채널(channel) 은 고루틴 사이에 값을 전달하는 통로다.
비유하자면 타입이 있는 컨베이어 벨트다.
- 한쪽 끝에서 값을 올린다 (송신)
- 다른 쪽 끝에서 값을 집는다 (수신)
- 한 번에 한 타입의 값만 흐른다
채널을 통해 두 고루틴은,
- 값을 주고받고 (통신)
- 서로 박자를 맞춘다 (동기화)
이 두 가지가 동시에 일어난다는 점이 채널의 매력이다.
채널 타입 표기
채널은 자신이 운반하는 값의 타입을 가진다.
chan int // int 가 흐르는 채널
chan string // string 이 흐르는 채널
chan []byte // 바이트 슬라이스가 흐르는 채널
chan T 형태로 읽으면 된다.
22.5 채널 만들고 쓰기
생성
ch := make(chan int)
make 로 만든다.
용량을 적지 않으면 언버퍼 채널(unbuffered channel) 이 된다.
송신과 수신
ch <- 5 // 5 를 ch 에 보낸다 (송신)
x := <-ch // ch 에서 값을 꺼낸다 (수신)
화살표가 채널을 향하면 송신, 채널에서 나오면 수신이다. 시각적으로 방향이 곧 의미라서 외우기 쉽다.
송수신은 서로를 기다린다
언버퍼 채널의 핵심 성질이다.
- 송신 측은 누군가 받기 시작할 때까지 멈춰 있다
- 수신 측은 누군가 보낼 때까지 멈춰 있다
즉, 만남이 성사돼야 둘 다 진행된다. 이걸 “랑데부(rendezvous)” 라고 부른다.
짧은 예제
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42 // 송신
}()
v := <-ch // 수신
fmt.Println(v)
}
실행 결과는 42.
흐름을 따라가 보자.
- main 이 채널을 만든다
- 고루틴을 띄우고 본인은
<-ch에서 대기 - 고루틴이
ch <- 42를 실행 → main 이 받는다 - main 이 값을 출력하고 종료
이 코드는 time.Sleep 없이도 안정적으로 동작한다.
채널이 박자를 맞춰 주기 때문이다.
한 가지 함정
ch := make(chan int)
ch <- 1 // 여기서 영원히 멈춤
fmt.Println(<-ch)
언버퍼 채널은 받는 쪽이 없으면 송신이 막힌다. 같은 고루틴(main) 안에서 송신과 수신을 차례로 하면, 첫 번째 송신이 영원히 끝나지 않는다.
이 상태가 바로 데드락(deadlock) 이다. Go 런타임이 모든 고루틴이 멈췄음을 감지하면 다음과 같은 패닉을 띄운다.
fatal error: all goroutines are asleep - deadlock!
이 메시지가 뜨면, “어딘가에서 받지 않는 채널에 보내고 있구나” 라고 의심해 보자.
22.6 채널 방향
채널은 양방향이 기본이지만, 함수 매개변수로 넘길 때는 방향을 제한할 수 있다.
chan T // 양방향
chan<- T // 송신 전용
<-chan T // 수신 전용
화살표 위치가 곧 방향이다.
chan<- 는 채널 안으로,
<-chan 은 채널 밖으로 화살이 향한다.
왜 방향을 제한하나
함수 시그니처만 봐도 역할이 드러나기 때문이다.
func producer(out chan<- int) {
for i := 0; i < 3; i++ {
out <- i
}
}
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
producer 는 보내기만 하고,
consumer 는 받기만 한다.
컴파일러가 실수를 막아 준다.
예를 들어 producer 안에서 <-out 으로 받으려 하면
컴파일 에러가 난다.
호출하는 쪽은 양방향 채널을 만들어 넘기면 된다.
ch := make(chan int)
go producer(ch)
consumer(ch)
양방향 채널은 어느 쪽 매개변수에도 자동으로 맞는다. 반대로 단방향 채널을 양방향으로 다시 만들 순 없다.
22.7 버퍼 채널
지금까지 본 채널은 모두 언버퍼였다. 한 번에 한 값만 흐르고, 송수신이 서로를 기다린다.
버퍼 채널은 큐 역할을 한다.
ch := make(chan int, 3) // 용량 3
동작 규칙:
- 버퍼에 자리가 있으면 송신은 즉시 통과
- 가득 차면 송신이 막힌다 (받는 사람이 빼낼 때까지)
- 비어 있으면 수신이 막힌다 (누군가 넣을 때까지)
예제
ch := make(chan int, 2)
ch <- 1
ch <- 2
// ch <- 3 // 여기서 막힌다
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
1 과 2 를 넣을 때는 막히지 않는다.
세 번째 송신부터 막힌다.
언제 버퍼를 쓰나
언버퍼가 기본이고, 버퍼는 “이런 이유로 필요해” 가 명확할 때만 쓴다.
쓸 만한 경우:
- 송신과 수신의 속도가 들쭉날쭉할 때 버퍼로 잠깐 차이를 완충
- 정해진 개수의 작업을 미리 큐에 쌓아 두고 워커가 가져가게 할 때
- 결과를 모으는 채널의 용량을 미리 정해 둘 때
쓰면 안 되는 경우:
- “데드락 났는데 버퍼 키우면 해결될까?” 식의 회피 → 보통 설계가 잘못된 신호다
버퍼는 성능을 위한 도구가 아니라 흐름의 모양을 정하는 도구다. 막연히 키운다고 빨라지는 일은 거의 없다.
22.8 채널 닫기와 range
채널을 다 썼다면 닫을(close) 수 있다.
close(ch)
닫힌 채널은 다음 성질을 가진다.
- 새로 송신하면 패닉이 발생한다
- 수신은 여전히 가능하지만,
버퍼에 남은 값을 다 꺼낸 뒤에는
제로값과 함께
ok = false를 돌려준다
ok 받기
수신할 때 두 번째 값으로 ok 를 받을 수 있다.
v, ok := <-ch
if !ok {
fmt.Println("채널이 닫혔다")
}
ok 가 false 면 채널이 닫혔고 더 받을 값도 없다는 뜻이다.
for range 로 자동 종료
매번 ok 를 확인하는 건 번거롭다.
for range 가 이걸 자동으로 해 준다.
ch := make(chan int, 3)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
송신 측이 close(ch) 를 호출하면
수신 측의 range 가 자연스럽게 종료된다.
누가 닫아야 하나
관례는 분명하다.
보내는 쪽이 닫는다. 받는 쪽이 닫지 않는다.
이유는,
- 받는 쪽은 송신이 끝났는지 알 길이 없다
- 닫힌 채널에 송신하면 패닉이 난다
- 보내는 쪽이 닫아야 패닉 위험이 없다
여러 송신자가 있다면 누가 닫을지 명확히 정해야 한다. 보통은 모든 송신자를 모은 뒤 마지막에 한 번만 닫는다. 이 패턴은 25장에서 다시 다룬다.
22.9 select 문 기초
여러 채널을 동시에 다뤄야 할 때 쓰는 도구가 select 다.
문법은 switch 와 닮았지만,
조건이 모두 채널 연산이다.
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case ch2 <- 42:
fmt.Println("sent to ch2")
}
동작 규칙:
- 모든 case 를 살펴 준비된 것을 찾는다
- 준비된 case 가 하나면 그 case 실행
- 여러 개면 임의로 하나를 골라 실행
- 하나도 준비 안 됐으면 막혀서 기다린다
default 케이스
기다리고 싶지 않다면 default 를 쓴다.
select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing ready")
}
- 준비된 case 가 있으면 그쪽 실행
- 없으면 즉시
default실행
이걸 “논블로킹(non-blocking) 채널 연산” 이라 부른다.
짧은 예제
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "ping"
}()
go func() {
time.Sleep(50 * time.Millisecond)
ch2 <- "pong"
}()
for i := 0; i < 2; i++ {
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}
}
}
ch2 가 먼저 준비되고 ch1 이 나중에 준비된다.
출력은 보통 이렇다.
ch2: pong
ch1: ping
select는 채널 기반 동시성 코드의 거의 모든 곳에 등장한다. 25장의 패턴들도 결국select의 응용이다.
22.10 sync.WaitGroup
여러 고루틴을 띄워 놓고 모두 끝날 때까지 기다리기. 이게 의외로 자주 필요하다.
이때 쓰는 도구가 sync.WaitGroup 이다.
사용 패턴
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait()
fmt.Println("all done")
}
세 가지 메서드가 핵심이다.
| 메서드 | 의미 |
|---|---|
Add(n) | 기다릴 고루틴 수를 n 만큼 늘린다 |
Done() | 고루틴 하나가 끝났음을 알린다 |
Wait() | 카운트가 0 이 될 때까지 막힌다 |
Done() 은 내부적으로 Add(-1) 과 같다.
관례: defer wg.Done()
고루틴 시작 직후 defer wg.Done() 을 적는 것이 관례다.
go func() {
defer wg.Done()
// ... 실제 작업 ...
}()
이유:
- 작업 중간에 어떤 경로로 빠져나가든 항상
Done이 호출된다 - panic 이 나도
defer는 실행되므로 안전하다
주의할 점
Add는 고루틴을 띄우기 전에 호출한다- 고루틴 안에서
Add를 하면Wait가 먼저 0을 보고 빠져나갈 수 있다
- 고루틴 안에서
Done을 한 번 더 호출하면 카운트가 음수가 되며 패닉이 난다WaitGroup은 값으로 복사하지 않는다- 함수에 넘길 때는 포인터로
채널 vs WaitGroup
같은 “기다림“이라도 도구를 나눠 쓰자.
| 상황 | 도구 |
|---|---|
| 결과 값을 모아야 한다 | 채널 |
| 끝났다는 사실만 알면 된다 | WaitGroup |
| 둘 다 필요하다 | 결과 채널 + WaitGroup |
마지막 경우의 정석 패턴은 25장에서 다룬다.
22.11 정리
이 장에서 살펴본 내용:
- 고루틴은 Go 런타임이 관리하는 가벼운 실행 단위다
go f()한 줄로 새 고루틴을 시작한다- main 이 끝나면 모든 고루틴이 강제 종료된다
- 채널은 타입이 있는 통로이며 송수신을 동기화한다
- 언버퍼 채널은 송수신이 만나야 진행되고, 버퍼 채널은 큐처럼 동작한다
- 채널 방향(
chan<-,<-chan)으로 의도를 분명히 한다 close와for range로 송신 종료를 깔끔히 전달한다select는 여러 채널 중 준비된 것을 처리한다sync.WaitGroup으로 고루틴 묶음의 끝을 기다린다
도구는 이제 손에 잡혔다. 하지만 도구가 있다고 안전한 코드가 되진 않는다. 여러 고루틴이 같은 데이터를 만지면 의외로 쉽게 망가진다.
다음 장에서는 그 망가지는 양상부터 들여다보고, 가장 기본적인 해결 도구인 뮤텍스(Mutex) 를 배운다.